@c9up/aurora 0.1.32 → 0.1.34

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.
@@ -76,7 +76,14 @@ export function buildLiveTransport(relayClient, http, options = {}) {
76
76
  return {
77
77
  subscribe: (channel, handler) => relayClient.subscribe(channel, handler),
78
78
  post: (id, event, payload) => {
79
- void http.post(path, { id, event, payload });
79
+ // A post that fails means the server never saw this interaction, so
80
+ // the component's server-side state and what the user is looking at
81
+ // have diverged — silence is the worst possible answer. Unhandled,
82
+ // it was also a bare `Uncaught (in promise)` with no clue which
83
+ // event was lost.
84
+ void (async () => http.post(path, { id, event, payload }))().catch((err) => {
85
+ console.warn(`[aurora/live] '${event}' on ${id} did not reach the server; this component is now out of sync:`, err);
86
+ });
80
87
  },
81
88
  };
82
89
  }
@@ -30,19 +30,23 @@ const renderScope = new AsyncLocalStorage();
30
30
  setCookieStoreReader(() => renderScope.getStore()?.cookies);
31
31
  setRouteManifestReader(() => renderScope.getStore()?.routes);
32
32
  function isCookieReadable(request) {
33
- return (typeof request === "object" &&
34
- request !== null &&
35
- "cookie" in request &&
36
- typeof request.cookie === "function");
33
+ if (typeof request !== "object" || request === null)
34
+ return false;
35
+ return (typeof Reflect.get(request, "plainCookie") === "function" ||
36
+ typeof Reflect.get(request, "cookie") === "function");
37
37
  }
38
38
  /** Read the allowlisted cookies off the request into a `name → value` seed. */
39
39
  function readRequestCookies(request, names) {
40
40
  if (!isCookieReadable(request))
41
41
  return {};
42
42
  const seed = {};
43
+ // Unsigned first; `cookie()` only when the host offers nothing else.
44
+ const read = request.plainCookie
45
+ ? (name) => request.plainCookie?.(name, undefined, { encoded: false })
46
+ : (name) => request.cookie?.(name);
43
47
  for (const name of names) {
44
- const value = request.cookie(name);
45
- if (value !== null)
48
+ const value = read(name);
49
+ if (value !== null && value !== undefined)
46
50
  seed[name] = value;
47
51
  }
48
52
  return seed;
package/dist/ssr.js CHANGED
@@ -45,19 +45,34 @@ function stringifyTemplateResult(result) {
45
45
  // the matching value, and consume the closing `"` from the next
46
46
  // segment. This three-step coordination is why the loop holds a
47
47
  // `pendingClosingQuote` flag.
48
- let pendingClosingQuote = false;
48
+ // Which quote closes the directive currently being skipped, so the closing
49
+ // one consumed is the one that was opened. Undefined when none is pending.
50
+ let pendingClosingQuote;
49
51
  const scanner = new TagScanner();
50
52
  for (let i = 0; i < strings.length; i++) {
51
53
  let segment = strings[i];
52
- if (pendingClosingQuote) {
53
- segment = segment.replace(/^"/, "");
54
- pendingClosingQuote = false;
54
+ if (pendingClosingQuote !== undefined) {
55
+ segment =
56
+ pendingClosingQuote === '"'
57
+ ? segment.replace(/^"/, "")
58
+ : segment.replace(/^'/, "");
59
+ pendingClosingQuote = undefined;
55
60
  }
56
- const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
61
+ // Both quote styles. Matching only `="` left `@click='${handler}'`
62
+ // unrecognised, so the handler fell through to `stringifyValue`, which
63
+ // CALLED it — a client event handler running on the server, its return
64
+ // value written into the HTML, and any exception swallowed.
65
+ const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
57
66
  const skipValue = directiveMatch !== null;
58
67
  if (directiveMatch) {
59
68
  segment = segment.slice(0, segment.length - directiveMatch[0].length);
60
- pendingClosingQuote = true;
69
+ // Only a quoted directive leaves a closing quote to swallow.
70
+ pendingClosingQuote =
71
+ directiveMatch[2] === '"'
72
+ ? '"'
73
+ : directiveMatch[2] === "'"
74
+ ? "'"
75
+ : undefined;
61
76
  }
62
77
  out += segment;
63
78
  scanner.consume(segment);
@@ -147,10 +162,10 @@ function stringifyValue(value, inAttribute) {
147
162
  if (isSignal(value))
148
163
  return stringifyValue(value(), inAttribute);
149
164
  if (typeof value === "function") {
150
- // In attribute position: directive handlers (`@click`, `?disabled`,
151
- // `.prop`) have already been stripped by `stripDirectiveBefore`.
152
- // A function reaching this point is a reactive-expression text
153
- // slot (`${() => ...}`), which we evaluate eagerly server-side.
165
+ // A function here is a reactive expression — `class="${() => …}"` in an
166
+ // attribute, `${() => …}` in text and is evaluated eagerly
167
+ // server-side. Directive values (`@click`, `?disabled`, `.prop`) never
168
+ // reach this point: the scanner skips them, whatever quoting they use.
154
169
  try {
155
170
  return stringifyValue(value(), inAttribute);
156
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,9 +56,9 @@
56
56
  "@biomejs/biome": "^2.4.10",
57
57
  "@c9up/comet": "^0.1.0",
58
58
  "@types/node": "^22.19.15",
59
- "@vitest/browser": "4.1.9",
60
- "@vitest/browser-playwright": "4.1.9",
61
- "happy-dom": "^15.11.7",
59
+ "@vitest/browser": "4.1.11",
60
+ "@vitest/browser-playwright": "4.1.11",
61
+ "happy-dom": "^20.12.0",
62
62
  "playwright": "^1.61.1",
63
63
  "typescript": "^6.0.2",
64
64
  "vitest": "4.1.9"
package/src/liveClient.ts CHANGED
@@ -118,7 +118,19 @@ export function buildLiveTransport(
118
118
  subscribe: (channel, handler) =>
119
119
  relayClient.subscribe<SlotPatch[]>(channel, handler),
120
120
  post: (id, event, payload) => {
121
- void http.post(path, { id, event, payload });
121
+ // A post that fails means the server never saw this interaction, so
122
+ // the component's server-side state and what the user is looking at
123
+ // have diverged — silence is the worst possible answer. Unhandled,
124
+ // it was also a bare `Uncaught (in promise)` with no clue which
125
+ // event was lost.
126
+ void (async () => http.post(path, { id, event, payload }))().catch(
127
+ (err: unknown) => {
128
+ console.warn(
129
+ `[aurora/live] '${event}' on ${id} did not reach the server; this component is now out of sync:`,
130
+ err,
131
+ );
132
+ },
133
+ );
122
134
  },
123
135
  };
124
136
  }
@@ -142,8 +142,24 @@ export type SharedPropsResolver = (
142
142
  ) => SharedProps | Promise<SharedProps>;
143
143
 
144
144
  /** A request that can read a cookie by name — the structural slice we need. */
145
+ /**
146
+ * The slice of a host request this needs: reading a cookie the browser wrote.
147
+ *
148
+ * `plainCookie` is the one to use. These cookies are written client-side
149
+ * through `document.cookie`, so they carry no signature; a host whose
150
+ * `cookie()` verifies one (as Ream's does) returns nothing for them, and the
151
+ * seed silently empties — which is the flash this exists to prevent.
152
+ *
153
+ * `cookie` is accepted as a fallback so a host that only offers that spelling
154
+ * still seeds, rather than rendering default state and saying nothing.
155
+ */
145
156
  interface CookieReadableRequest {
146
- cookie(name: string): string | null;
157
+ plainCookie?<T = string>(
158
+ name: string,
159
+ defaultValue?: T,
160
+ options?: { encoded?: boolean },
161
+ ): T | null;
162
+ cookie?(name: string): string | null;
147
163
  }
148
164
 
149
165
  interface RenderScope {
@@ -157,11 +173,10 @@ setCookieStoreReader(() => renderScope.getStore()?.cookies);
157
173
  setRouteManifestReader(() => renderScope.getStore()?.routes);
158
174
 
159
175
  function isCookieReadable(request: unknown): request is CookieReadableRequest {
176
+ if (typeof request !== "object" || request === null) return false;
160
177
  return (
161
- typeof request === "object" &&
162
- request !== null &&
163
- "cookie" in request &&
164
- typeof request.cookie === "function"
178
+ typeof Reflect.get(request, "plainCookie") === "function" ||
179
+ typeof Reflect.get(request, "cookie") === "function"
165
180
  );
166
181
  }
167
182
 
@@ -172,9 +187,14 @@ function readRequestCookies(
172
187
  ): Record<string, string> {
173
188
  if (!isCookieReadable(request)) return {};
174
189
  const seed: Record<string, string> = {};
190
+ // Unsigned first; `cookie()` only when the host offers nothing else.
191
+ const read = request.plainCookie
192
+ ? (name: string) =>
193
+ request.plainCookie?.<string>(name, undefined, { encoded: false })
194
+ : (name: string) => request.cookie?.(name);
175
195
  for (const name of names) {
176
- const value = request.cookie(name);
177
- if (value !== null) seed[name] = value;
196
+ const value = read(name);
197
+ if (value !== null && value !== undefined) seed[name] = value;
178
198
  }
179
199
  return seed;
180
200
  }
package/src/ssr.ts CHANGED
@@ -49,19 +49,34 @@ function stringifyTemplateResult(result: TemplateResult): string {
49
49
  // the matching value, and consume the closing `"` from the next
50
50
  // segment. This three-step coordination is why the loop holds a
51
51
  // `pendingClosingQuote` flag.
52
- let pendingClosingQuote = false;
52
+ // Which quote closes the directive currently being skipped, so the closing
53
+ // one consumed is the one that was opened. Undefined when none is pending.
54
+ let pendingClosingQuote: '"' | "'" | undefined;
53
55
  const scanner = new TagScanner();
54
56
  for (let i = 0; i < strings.length; i++) {
55
57
  let segment = strings[i];
56
- if (pendingClosingQuote) {
57
- segment = segment.replace(/^"/, "");
58
- pendingClosingQuote = false;
58
+ if (pendingClosingQuote !== undefined) {
59
+ segment =
60
+ pendingClosingQuote === '"'
61
+ ? segment.replace(/^"/, "")
62
+ : segment.replace(/^'/, "");
63
+ pendingClosingQuote = undefined;
59
64
  }
60
- const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
65
+ // Both quote styles. Matching only `="` left `@click='${handler}'`
66
+ // unrecognised, so the handler fell through to `stringifyValue`, which
67
+ // CALLED it — a client event handler running on the server, its return
68
+ // value written into the HTML, and any exception swallowed.
69
+ const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
61
70
  const skipValue = directiveMatch !== null;
62
71
  if (directiveMatch) {
63
72
  segment = segment.slice(0, segment.length - directiveMatch[0].length);
64
- pendingClosingQuote = true;
73
+ // Only a quoted directive leaves a closing quote to swallow.
74
+ pendingClosingQuote =
75
+ directiveMatch[2] === '"'
76
+ ? '"'
77
+ : directiveMatch[2] === "'"
78
+ ? "'"
79
+ : undefined;
65
80
  }
66
81
  out += segment;
67
82
  scanner.consume(segment);
@@ -148,10 +163,10 @@ function stringifyValue(value: unknown, inAttribute: boolean): string {
148
163
  if (value === true) return inAttribute ? "" : "true";
149
164
  if (isSignal(value)) return stringifyValue(value(), inAttribute);
150
165
  if (typeof value === "function") {
151
- // In attribute position: directive handlers (`@click`, `?disabled`,
152
- // `.prop`) have already been stripped by `stripDirectiveBefore`.
153
- // A function reaching this point is a reactive-expression text
154
- // slot (`${() => ...}`), which we evaluate eagerly server-side.
166
+ // A function here is a reactive expression — `class="${() => …}"` in an
167
+ // attribute, `${() => …}` in text and is evaluated eagerly
168
+ // server-side. Directive values (`@click`, `?disabled`, `.prop`) never
169
+ // reach this point: the scanner skips them, whatever quoting they use.
155
170
  try {
156
171
  return stringifyValue((value as () => unknown)(), inAttribute);
157
172
  } catch {