@tinvar/react 0.0.0-stage → 0.4.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carson Hopper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,170 @@
1
1
  {
2
2
  "name": "@tinvar/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "license": "MIT",
5
+ "files": [
6
+ "src"
7
+ ],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "type": "module",
12
+ "main": "./src/index.js",
13
+ "exports": {
14
+ ".": {
15
+ "react-server": {
16
+ "types": "./src/index.server.d.ts",
17
+ "default": "./src/index.server.js"
18
+ },
19
+ "browser": {
20
+ "types": "./src/index.d.ts",
21
+ "default": "./src/index.js"
22
+ },
23
+ "default": {
24
+ "types": "./src/index.combined.d.ts",
25
+ "default": "./src/index.js"
26
+ }
27
+ },
28
+ "./server": {
29
+ "types": "./src/server.d.ts",
30
+ "default": "./src/server.js"
31
+ },
32
+ "./deferred": {
33
+ "types": "./src/deferred.d.ts",
34
+ "default": "./src/deferred.jsx"
35
+ },
36
+ "./deferred-boundary": {
37
+ "types": "./src/deferred-boundary.d.ts",
38
+ "default": "./src/deferred-boundary.js"
39
+ },
40
+ "./client": {
41
+ "types": "./src/client.d.ts",
42
+ "default": "./src/client.js"
43
+ },
44
+ "./link": {
45
+ "types": "./src/link.d.ts",
46
+ "default": "./src/link.js"
47
+ },
48
+ "./route": {
49
+ "types": "./src/route.d.ts",
50
+ "default": "./src/route.js"
51
+ },
52
+ "./endpoint": {
53
+ "types": "./src/endpoint.d.ts",
54
+ "default": "./src/endpoint.js"
55
+ },
56
+ "./action": {
57
+ "types": "./src/action.d.ts",
58
+ "react-server": "./src/action-server.js",
59
+ "default": "./src/action.js"
60
+ },
61
+ "./form": {
62
+ "types": "./src/forms.d.ts",
63
+ "default": "./src/forms.js"
64
+ },
65
+ "./rescue": {
66
+ "types": "./src/rescue.d.ts",
67
+ "default": "./src/rescue.js"
68
+ },
69
+ "./head": {
70
+ "types": "./src/head.d.ts",
71
+ "default": "./src/head.js"
72
+ },
73
+ "./segment": {
74
+ "types": "./src/segment.d.ts",
75
+ "default": "./src/segment.js"
76
+ },
77
+ "./layout-props": {
78
+ "types": "./src/layout-props.d.ts",
79
+ "default": "./src/layout-props.js"
80
+ },
81
+ "./compose-route": {
82
+ "types": "./src/compose-route.d.ts",
83
+ "default": "./src/compose-route.js"
84
+ },
85
+ "./poll": {
86
+ "types": "./src/poll.d.ts",
87
+ "default": "./src/poll.js"
88
+ },
89
+ "./progress": {
90
+ "types": "./src/progress.d.ts",
91
+ "default": "./src/progress.js"
92
+ },
93
+ "./use-remember": {
94
+ "types": "./src/use-remember.d.ts",
95
+ "default": "./src/use-remember.js"
96
+ },
97
+ "./once": {
98
+ "types": "./src/once.d.ts",
99
+ "default": "./src/once.js"
100
+ },
101
+ "./once-slot": {
102
+ "types": "./src/once-slot.d.ts",
103
+ "default": "./src/once-slot.js"
104
+ },
105
+ "./merge": {
106
+ "types": "./src/merge.d.ts",
107
+ "default": "./src/merge.js"
108
+ },
109
+ "./merge-slot": {
110
+ "types": "./src/merge-slot.d.ts",
111
+ "default": "./src/merge-slot.js"
112
+ },
113
+ "./infinite-scroll": {
114
+ "types": "./src/infinite-scroll.d.ts",
115
+ "default": "./src/infinite-scroll.js"
116
+ },
117
+ "./live": {
118
+ "types": "./src/live.d.ts",
119
+ "default": "./src/live.js"
120
+ },
121
+ "./channel": {
122
+ "types": "./src/channel.d.ts",
123
+ "default": "./src/channel.js"
124
+ },
125
+ "./use-channel": {
126
+ "types": "./src/use-channel.d.ts",
127
+ "default": "./src/use-channel.js"
128
+ },
129
+ "./inbox": {
130
+ "types": "./src/inbox.d.ts",
131
+ "default": "./src/inbox.js"
132
+ },
133
+ "./use-inbox": {
134
+ "types": "./src/use-inbox.d.ts",
135
+ "default": "./src/use-inbox.js"
136
+ },
137
+ "./inbox-view": {
138
+ "types": "./src/inbox-view.d.ts",
139
+ "default": "./src/inbox-view.js"
140
+ },
141
+ "./upload": {
142
+ "types": "./src/upload.d.ts",
143
+ "default": "./src/upload.js"
144
+ },
145
+ "./transfer": {
146
+ "types": "./src/transfer.d.ts",
147
+ "default": "./src/transfer.js"
148
+ },
149
+ "./optional": {
150
+ "types": "./src/optional.d.ts",
151
+ "default": "./src/optional.js"
152
+ },
153
+ "./telemetry": {
154
+ "types": "./src/telemetry.d.ts",
155
+ "default": "./src/telemetry.js"
156
+ },
157
+ "./when-mounted": {
158
+ "types": "./src/when-mounted.d.ts",
159
+ "default": "./src/when-mounted.js"
160
+ },
161
+ "./testing": {
162
+ "types": "./src/testing.d.ts",
163
+ "default": "./src/testing.js"
164
+ }
165
+ },
166
+ "peerDependencies": {
167
+ "react": "^19.0.0"
168
+ },
169
+ "types": "./src/index.d.ts"
170
+ }
@@ -0,0 +1,41 @@
1
+ // Action references as a server component sees them: plain data.
2
+ //
3
+ // The spec passes a reference from a server component to a client one --
4
+ // `<RackGrid onMove={moveUnit} />` -- and Flight serializes nothing it cannot
5
+ // rebuild on the other side. A function is refused ("Functions cannot be
6
+ // passed directly to Client Components"), and the renderer never decodes
7
+ // server-function references, by design: "Actions go to PHP." So under the
8
+ // `react-server` condition a reference is `{ action, url }`, which crosses as
9
+ // it is. Everything on the client that takes an action reads that shape, and
10
+ // action() there turns it back into something callable.
11
+ //
12
+ // A server component never calls one: the renderer does not post to PHP.
13
+
14
+ export const ACTION_PREFIX = '/_action/';
15
+
16
+ /**
17
+ * @param {string | { readonly action: string }} target
18
+ * @returns {string}
19
+ */
20
+ export function actionName(target) {
21
+ const name = typeof target === 'string' ? target : target?.action;
22
+ if (typeof name !== 'string' || name === '') {
23
+ throw new TypeError('[tinvar] An action is its name, or a reference from @actions/*.');
24
+ }
25
+ return name;
26
+ }
27
+
28
+ /** @param {string | { readonly action: string }} target */
29
+ export function actionUrl(target) {
30
+ return ACTION_PREFIX + encodeURIComponent(actionName(target));
31
+ }
32
+
33
+ /** A server component's reference to a PHP action: data to hand a client component. */
34
+ export function action(/** @type {string | { readonly action: string }} */ target) {
35
+ const name = actionName(target);
36
+ return Object.freeze({ action: name, url: actionUrl(name) });
37
+ }
38
+
39
+ export function isActionReference() {
40
+ return false;
41
+ }
@@ -0,0 +1,68 @@
1
+ /** Action references: what codegen's `@actions/*` modules export. */
2
+
3
+ export declare const ACTION_PREFIX: '/_action/';
4
+
5
+ /** What the action endpoint answers with; codegen emits the same shape as `ActionResult`. */
6
+ export interface ActionResponse {
7
+ value: unknown;
8
+ flash: Record<string, unknown>;
9
+ revalidated: string[];
10
+ payloads: Record<string, string>;
11
+ /** Where the action redirected, when it did. */
12
+ redirect?: string;
13
+ }
14
+
15
+ /** An action, as anything that takes one accepts it: its name, or a reference. */
16
+ export type ActionTarget = string | { readonly action: string };
17
+
18
+ export interface CallOptions {
19
+ signal?: AbortSignal;
20
+ headers?: Record<string, string>;
21
+ }
22
+
23
+ /**
24
+ * A reference to one PHP action. Call it to post; give it to `<Form action>`,
25
+ * `useForm().submit()` or `useHttp()` to have the page follow the answer.
26
+ *
27
+ * Under the `react-server` condition it is the plain `{ action, url }`, which
28
+ * a server component can hand a client component.
29
+ */
30
+ export interface ActionReference<Args extends object = Record<string, unknown>, Result = ActionResponse> {
31
+ (args: Args, options?: CallOptions): Promise<Result>;
32
+ readonly action: string;
33
+ readonly url: string;
34
+ /**
35
+ * The same action, patching the nearest `<OptimisticState>` while it runs,
36
+ * when submitted through `<Form>` or useForm. The patch returns the keys it
37
+ * changes, and is replayed as other requests settle, so it must be pure.
38
+ */
39
+ optimistic<S extends Record<string, unknown> = Record<string, any>>(
40
+ patch: (state: S) => Partial<S>,
41
+ ): ActionReference<Args, Result>;
42
+ }
43
+
44
+ /** A reference to the PHP action named, or to the one a plain `{ action }` names. */
45
+ export declare function action<Args extends object = Record<string, unknown>, Result = ActionResponse>(
46
+ target: ActionTarget,
47
+ ): ActionReference<Args, Result>;
48
+
49
+ /** The action's wire name, from its name or a reference. */
50
+ export declare function actionName(target: ActionTarget): string;
51
+
52
+ /** Where the action is posted: `/_action/{name}`. */
53
+ export declare function actionUrl(target: ActionTarget): string;
54
+
55
+ export declare function isActionReference(value: unknown): value is ActionReference;
56
+
57
+ /** The optimistic patch a reference carries, or null. */
58
+ export declare function optimisticPatch(
59
+ target: ActionTarget,
60
+ ): ((state: Record<string, any>) => Record<string, unknown>) | null;
61
+
62
+ /** Why a direct call did not succeed: which channel, the status, and any field errors. */
63
+ export declare class ActionError extends Error {
64
+ readonly kind: 'validation' | 'http' | 'expired' | 'network' | 'cancelled';
65
+ /** 0 when there was no answer. */
66
+ readonly status: number;
67
+ readonly errors: Record<string, string[]>;
68
+ }
package/src/action.js ADDED
@@ -0,0 +1,167 @@
1
+ // Action references: what codegen's `@actions/*` modules export.
2
+ //
3
+ // tinvar-spec.md, "Actions and revalidation": "codegen emits typed
4
+ // TypeScript stubs that behave like Next.js server actions":
5
+ //
6
+ // import { moveUnit } from '@actions/RackActions';
7
+ // <form action={moveUnit}>...</form>
8
+ // <RackGrid onMove={moveUnit} />
9
+ //
10
+ // So a reference is three things at once: a function that posts to its PHP
11
+ // action, a name that <Form>, useForm and useHttp read to know where to post,
12
+ // and -- with `.optimistic(patch)` -- a variant that patches client state
13
+ // while it runs (optimistic.js, ADR 0016). Anything that takes an action
14
+ // takes a reference or the action's name as a string; actionName() reads
15
+ // either.
16
+ //
17
+ // Under the `react-server` condition, action-server.js stands in: a server
18
+ // component can only hand a client component what Flight can serialize, and
19
+ // a function is not that.
20
+
21
+ import { send } from './transport.js';
22
+
23
+ export const ACTION_PREFIX = '/_action/';
24
+
25
+ /** Marks a reference, so a function that merely has an `action` property is not mistaken for one. */
26
+ const REFERENCE = Symbol.for('tinvar.action');
27
+
28
+ /**
29
+ * What an action is given as: its name, a reference, or the plain
30
+ * `{ action }` a server component hands across.
31
+ *
32
+ * @typedef {string | { readonly action: string }} ActionTarget
33
+ */
34
+
35
+ /**
36
+ * The action's wire name.
37
+ *
38
+ * @param {ActionTarget} target
39
+ * @returns {string}
40
+ */
41
+ export function actionName(target) {
42
+ const name = typeof target === 'string' ? target : target?.action;
43
+ if (typeof name !== 'string' || name === '') {
44
+ throw new TypeError('[tinvar] An action is its name, or a reference from @actions/*.');
45
+ }
46
+ return name;
47
+ }
48
+
49
+ /**
50
+ * Where an action is posted. Encoded, though generated names never need it:
51
+ * a name taken from anywhere else stays one path segment.
52
+ *
53
+ * @param {ActionTarget} target
54
+ */
55
+ export function actionUrl(target) {
56
+ return ACTION_PREFIX + encodeURIComponent(actionName(target));
57
+ }
58
+
59
+ /** @param {unknown} value */
60
+ export function isActionReference(value) {
61
+ return typeof value === 'function' && /** @type {any} */ (value)[REFERENCE] === true;
62
+ }
63
+
64
+ /**
65
+ * The optimistic patch a reference carries, or null.
66
+ *
67
+ * @param {ActionTarget} target
68
+ * @returns {((state: Record<string, any>) => Record<string, unknown>) | null}
69
+ */
70
+ export function optimisticPatch(target) {
71
+ return isActionReference(target) ? /** @type {any} */ (target.optimisticPatch ?? null) : null;
72
+ }
73
+
74
+ /**
75
+ * A reference to a PHP action.
76
+ *
77
+ * @param {ActionTarget} target
78
+ */
79
+ export function action(target) {
80
+ return reference(actionName(target), null);
81
+ }
82
+
83
+ /**
84
+ * @param {string} name
85
+ * @param {((state: Record<string, any>) => Record<string, unknown>) | null} patch
86
+ */
87
+ function reference(name, patch) {
88
+ /**
89
+ * Called directly, it posts and resolves with what the endpoint answered.
90
+ * It does not move the page -- no redirect followed, no payload mounted --
91
+ * because nothing here knows the page: <Form>, useForm and useHttp do,
92
+ * and are how a page follows an action. A failure rejects with an
93
+ * ActionError naming which of the three channels it came by.
94
+ *
95
+ * @param {Record<string, unknown>} [args]
96
+ * @param {{ signal?: AbortSignal, headers?: Record<string, string> }} [options]
97
+ */
98
+ const call = async (args = {}, options = {}) => {
99
+ const outcome = await send({
100
+ url: ACTION_PREFIX + encodeURIComponent(name),
101
+ data: args,
102
+ headers: options.headers,
103
+ signal: options.signal,
104
+ });
105
+
106
+ if (outcome.type === 'success')
107
+ return outcome.body ?? { value: null, flash: {}, revalidated: [], payloads: {} };
108
+ if (outcome.type === 'redirect' || outcome.type === 'location') {
109
+ return { value: null, flash: {}, revalidated: [], payloads: {}, redirect: outcome.location };
110
+ }
111
+ throw new ActionError(name, outcome);
112
+ };
113
+
114
+ Object.defineProperties(call, {
115
+ action: { value: name, enumerable: true },
116
+ url: { value: ACTION_PREFIX + encodeURIComponent(name), enumerable: true },
117
+ optimisticPatch: { value: patch },
118
+ [REFERENCE]: { value: true },
119
+ optimistic: {
120
+ /**
121
+ * The same action, patching the nearest <OptimisticState> while it
122
+ * runs -- through <Form>, useForm or useHttp, which know where that
123
+ * is. The patch takes the current state and returns the keys it
124
+ * changes, and is replayed as other requests settle, so it must be
125
+ * pure.
126
+ *
127
+ * @param {(state: Record<string, any>) => Record<string, unknown>} next
128
+ */
129
+ value: (next) => {
130
+ if (typeof next !== 'function') {
131
+ throw new TypeError('[tinvar] optimistic() takes a function from the current state to a patch.');
132
+ }
133
+ return reference(name, next);
134
+ },
135
+ },
136
+ });
137
+
138
+ return Object.freeze(call);
139
+ }
140
+
141
+ /**
142
+ * Why a direct call did not succeed: field errors (422), another status, or
143
+ * no answer at all.
144
+ */
145
+ export class ActionError extends Error {
146
+ /**
147
+ * @param {string} name
148
+ * @param {import('./transport.js').Outcome} outcome
149
+ */
150
+ constructor(name, outcome) {
151
+ super(
152
+ outcome.type === 'validation'
153
+ ? `The action ${name} was refused: the input is invalid.`
154
+ : outcome.type === 'network'
155
+ ? `The action ${name} could not be reached.`
156
+ : `The action ${name} failed${'status' in outcome ? ` with ${outcome.status}` : ''}.`,
157
+ );
158
+ this.name = 'ActionError';
159
+ /** 'validation', 'http', 'expired', 'network' or 'cancelled'. */
160
+ this.kind = outcome.type;
161
+ /** 0 when there was no answer. */
162
+ this.status = 'status' in outcome ? outcome.status : outcome.type === 'expired' ? 419 : 0;
163
+ /** @type {Record<string, string[]>} */
164
+ this.errors = outcome.type === 'validation' ? outcome.errors : {};
165
+ this.outcome = outcome;
166
+ }
167
+ }
@@ -0,0 +1,153 @@
1
+ // Trading a statically built page's placeholders for fragment tokens.
2
+ //
3
+ // tinvar-spec.md: "Fragments inside static pages get their tokens from a
4
+ // small per-visitor bootstrap request, never from the build." A page built
5
+ // by `tinvar build:static` is one file for every visitor, so its <Deferred>
6
+ // boundaries carry no token -- a token minted at build time would have no
7
+ // session to bind to, and would be everyone's. Each carries a placeholder
8
+ // instead: `b1.` and base64url JSON of the page's route, the params it was
9
+ // built for and the boundary's id. Nothing in it is signed or secret.
10
+ //
11
+ // When a boundary first loads, this asks POST /_bootstrap for the page's
12
+ // tokens, minted for the visitor's own session from what the build recorded
13
+ // -- never from the placeholder, which only says which page this is -- and
14
+ // the boundary loads with its token like any other.
15
+
16
+ import { csrfHeaders } from './csrf.js';
17
+
18
+ export const BOOTSTRAP_PATH = '/_bootstrap';
19
+
20
+ const PLACEHOLDER = /^b1\.([A-Za-z0-9_-]+)$/;
21
+
22
+ /**
23
+ * Whether a boundary's token is a static page's placeholder.
24
+ *
25
+ * @param {string | null | undefined} token
26
+ */
27
+ export function isPlaceholder(token) {
28
+ return typeof token === 'string' && PLACEHOLDER.test(token);
29
+ }
30
+
31
+ /**
32
+ * What a placeholder names, or null for anything that is not one.
33
+ *
34
+ * @param {string} token
35
+ * @returns {{ route: string, params: Record<string, string>, id: string } | null}
36
+ */
37
+ export function placeholderTarget(token) {
38
+ const match = PLACEHOLDER.exec(token);
39
+ if (!match) return null;
40
+ try {
41
+ const json = atob(match[1].replace(/-/g, '+').replace(/_/g, '/'));
42
+ const { r, p, b } = JSON.parse(json);
43
+ if (
44
+ typeof r !== 'string' ||
45
+ typeof b !== 'string' ||
46
+ typeof p !== 'object' ||
47
+ p === null ||
48
+ Array.isArray(p)
49
+ ) {
50
+ return null;
51
+ }
52
+ return { route: r, params: p, id: b };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export class BootstrapError extends Error {
59
+ /** @param {string} message @param {number} status */
60
+ constructor(message, status) {
61
+ super(message);
62
+ this.name = 'BootstrapError';
63
+ this.status = status;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The page's requests in flight, per fetch implementation, by page: the
69
+ * boundaries one trigger loads together ask once between them. Dropped once
70
+ * settled, so a boundary loading an hour later asks again rather than taking
71
+ * tokens that have since expired.
72
+ *
73
+ * @type {WeakMap<object, Map<string, Promise<Record<string, string>>>>}
74
+ */
75
+ const inFlight = new WeakMap();
76
+
77
+ /**
78
+ * The token for one placeholder, from /_bootstrap.
79
+ *
80
+ * @param {string} placeholder
81
+ * @param {typeof fetch} fetchImpl
82
+ * @returns {Promise<string>}
83
+ */
84
+ export async function bootstrapToken(placeholder, fetchImpl) {
85
+ const target = placeholderTarget(placeholder);
86
+ if (!target) {
87
+ throw new BootstrapError('Not a static page placeholder.', 404);
88
+ }
89
+
90
+ const key = JSON.stringify([target.route, target.params]);
91
+ let pages = inFlight.get(fetchImpl);
92
+ if (!pages) inFlight.set(fetchImpl, (pages = new Map()));
93
+
94
+ let pending = pages.get(key);
95
+ if (!pending) {
96
+ pending = requestTokens(target.route, target.params, fetchImpl).finally(() => pages.delete(key));
97
+ pages.set(key, pending);
98
+ }
99
+
100
+ const token = (await pending)[target.id];
101
+ if (typeof token !== 'string' || token === '') {
102
+ // The server does not know this page as built -- a page left over from
103
+ // an earlier build -- so nothing will make this boundary load but a
104
+ // fresh copy of the page.
105
+ throw new BootstrapError('This page is out of date; reload it.', 404);
106
+ }
107
+ return token;
108
+ }
109
+
110
+ /**
111
+ * POST /_bootstrap for one page.
112
+ *
113
+ * A visitor arriving at a static page may have no session yet, and so no
114
+ * CSRF cookie: the first request answers 419 and sets both, and is made once
115
+ * more with the token it set. The server answers 409 to a request with no
116
+ * session to mint for, which the same retry covers.
117
+ *
118
+ * @param {string} route
119
+ * @param {Record<string, string>} params
120
+ * @param {typeof fetch} fetchImpl
121
+ * @returns {Promise<Record<string, string>>}
122
+ */
123
+ async function requestTokens(route, params, fetchImpl) {
124
+ const send = () =>
125
+ fetchImpl(BOOTSTRAP_PATH, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...csrfHeaders() },
128
+ body: JSON.stringify({ route, params }),
129
+ credentials: 'same-origin',
130
+ // As a fragment's own request: a redirect off this origin could hand
131
+ // back tokens of someone else's choosing.
132
+ mode: 'same-origin',
133
+ redirect: 'error',
134
+ cache: 'no-store',
135
+ });
136
+
137
+ let response = await send();
138
+ if (response.status === 419 || response.status === 409) response = await send();
139
+
140
+ if (!response.ok) {
141
+ throw new BootstrapError(`Bootstrap failed with ${response.status}.`, response.status);
142
+ }
143
+
144
+ const body = await response.json().catch(() => null);
145
+ const tokens = body?.tokens;
146
+ // A page the server built nothing for is answered with none, which PHP
147
+ // writes as an empty list.
148
+ if (Array.isArray(tokens) && tokens.length === 0) return {};
149
+ if (typeof tokens !== 'object' || tokens === null || Array.isArray(tokens)) {
150
+ throw new BootstrapError('Bootstrap answered without tokens.', response.status);
151
+ }
152
+ return tokens;
153
+ }
@@ -0,0 +1,46 @@
1
+ /** Subscribing to the application's #[Channel]s (ADR 0069). */
2
+
3
+ export declare const CHANNEL_PATH: '/_channel/';
4
+
5
+ /** What one channel takes and carries, as codegen describes it. */
6
+ export interface ChannelShape {
7
+ params: object;
8
+ message: unknown;
9
+ }
10
+
11
+ /** A subscription the server refused, or that could not be asked for. */
12
+ export declare class ChannelError extends Error {
13
+ readonly channel: string;
14
+ /** 0 when the request never got an answer. */
15
+ readonly status: number;
16
+ }
17
+
18
+ export interface ListenOptions {
19
+ fetch?: typeof fetch;
20
+ document?: Document;
21
+ /** A refusal -- 401, 403, 404 -- or a request that never got an answer. Nothing is retried. */
22
+ onError?: (error: ChannelError) => void;
23
+ /** The stream was down for a while: messages may have been missed, and the hub keeps none to replay. */
24
+ onGap?: () => void;
25
+ }
26
+
27
+ /** Subscribes to one channel until the returned function is called. */
28
+ export declare function listen(
29
+ name: string,
30
+ params: Record<string, string | number | boolean>,
31
+ onMessage: (message: unknown) => void,
32
+ options?: ListenOptions,
33
+ ): () => void;
34
+
35
+ export interface Channels<M extends { [N in keyof M]: ChannelShape }> {
36
+ /** Subscribes to a channel by name until the returned function is called. */
37
+ listen<N extends keyof M & string>(
38
+ name: N,
39
+ params: M[N]['params'],
40
+ onMessage: (message: M[N]['message']) => void,
41
+ options?: ListenOptions,
42
+ ): () => void;
43
+ }
44
+
45
+ /** Builds the typed `channel` codegen exports. */
46
+ export declare function channels<M extends { [N in keyof M]: ChannelShape }>(): Channels<M>;