@getstrata/core 2.2.13 → 2.2.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.14
4
+
5
+ Complete the scoped HTML rendering starter adoption: generated apps inject ViewRenderer through createViewProvider instead of copying renderPage; welcome pages opt into HTMX fragments with full-page fallbacks, auth retains existing contracts, and CORS preserves representation Vary headers. Includes generator, runtime auth and cache-header regressions.
6
+
7
+ Add optional typed resourceRoutes and strict mergeRouteMaps composition, preserving native Bun routing and app-owned middleware. Add explicit ViewRenderer.namespace prefixes with unchanged layout/Response/HTMX contracts; generated auth controllers use the namespace. Includes native HTTP, compile-time, rendering isolation and generator regressions.
8
+
3
9
  ## 2.2.13
4
10
 
5
11
  - Improve view rendering and better htmx support
@@ -14,8 +14,8 @@ export { parseMultipartUpload, sanitizeUploadFileName, validateUploadFile, } fro
14
14
  export { createRequireAuthMiddleware } from "./requireAuthMiddleware";
15
15
  export { serializeDate, toPaginatedResourceCollection, toResourceCollection } from "./resources";
16
16
  export { createdResponse, errorResponse, jsonResponse, noContentResponse, withErrorHandling, } from "./response";
17
- export type { RouteRequest } from "./route";
18
- export { getRouteParams } from "./route";
17
+ export type { ResourceActions, ResourceMemberHandler, ResourceRouteMap, RouteRequest, } from "./route";
18
+ export { getRouteParams, mergeRouteMaps, resourceRoutes } from "./route";
19
19
  export { withMiddleware } from "./routeMiddleware";
20
20
  export { bindRouteModel } from "./routeModelBinding";
21
21
  export { securedBindRouteModel, securedBindRouteModelByKey, } from "./securedRouteModelBinding";
@@ -1,3 +1,67 @@
1
+ import type { RouteHandler } from "./middleware";
2
+ type ResourceMemberHandler = RouteHandler<RouteRequest<{
3
+ id: string;
4
+ }>>;
5
+ interface ResourceActions {
6
+ index?: RouteHandler;
7
+ create?: RouteHandler;
8
+ store?: RouteHandler;
9
+ show?: ResourceMemberHandler;
10
+ edit?: ResourceMemberHandler;
11
+ update?: ResourceMemberHandler;
12
+ destroy?: ResourceMemberHandler;
13
+ }
14
+ type ResourceRouteMap<P extends string, A extends ResourceActions> = (A extends {
15
+ index: infer H;
16
+ } ? {
17
+ [K in P]: {
18
+ GET: H;
19
+ };
20
+ } : Record<never, never>) & (A extends {
21
+ store: infer H;
22
+ } ? {
23
+ [K in P]: {
24
+ POST: H;
25
+ };
26
+ } : Record<never, never>) & (A extends {
27
+ create: infer H;
28
+ } ? {
29
+ [K in `${P}/create`]: {
30
+ GET: H;
31
+ };
32
+ } : Record<never, never>) & (A extends {
33
+ show: infer H;
34
+ } ? {
35
+ [K in `${P}/:id`]: {
36
+ GET: H;
37
+ };
38
+ } : Record<never, never>) & (A extends {
39
+ edit: infer H;
40
+ } ? {
41
+ [K in `${P}/:id/edit`]: {
42
+ GET: H;
43
+ };
44
+ } : Record<never, never>) & (A extends {
45
+ update: infer H;
46
+ } ? {
47
+ [K in `${P}/:id`]: {
48
+ PUT: H;
49
+ PATCH: H;
50
+ };
51
+ } : Record<never, never>) & (A extends {
52
+ destroy: infer H;
53
+ } ? {
54
+ [K in `${P}/:id`]: {
55
+ DELETE: H;
56
+ };
57
+ } : Record<never, never>);
58
+ /** Expand only explicitly supplied actions into native Bun routes. */
59
+ declare function resourceRoutes<const P extends string, const A extends ResourceActions>(path: P, actions: A & Record<Exclude<keyof A, keyof ResourceActions>, never>): ResourceRouteMap<P, A>;
60
+ type Intersection<U> = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void ? I : never;
61
+ /** Strict composition: duplicate paths fail rather than silently replacing handlers. */
62
+ declare function mergeRouteMaps<const T extends readonly Record<string, unknown>[]>(...maps: T): Intersection<T[number]>;
63
+ export type { ResourceActions, ResourceMemberHandler, ResourceRouteMap };
64
+ export { mergeRouteMaps, resourceRoutes };
1
65
  type RouteRequest<TParams extends Record<string, string>> = Request & {
2
66
  params: TParams;
3
67
  };
@@ -2,7 +2,7 @@ export type { LayoutDataResolver, RenderOptions } from "./etaViewEngine";
2
2
  export { DEFAULT_VIEWS_DIRECTORY, EtaViewEngine } from "./etaViewEngine";
3
3
  export { htmlResponse, isHtmxRequest, redirectResponse, rssResponse, textResponse, xmlResponse, } from "./htmlResponse";
4
4
  export type { ViewEngine } from "./viewEngine";
5
- export type { HtmxViewResponseOptions, ViewResponseOptions } from "./viewRenderer";
5
+ export type { HtmxViewResponseOptions, NamespacedViewRenderer, ViewResponseOptions, } from "./viewRenderer";
6
6
  export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer } from "./viewRenderer";
7
7
  export type { WebErrorViewInput, WebErrorViewOptions } from "./webErrorView";
8
8
  export { configureWebErrorView, errorTemplateName, htmlErrorResponse, notFoundHtmlResponse, renderKernelErrorChrome, renderWebErrorHtml, } from "./webErrorView";
@@ -10,15 +10,21 @@ interface HtmxViewResponseOptions extends ViewResponseOptions {
10
10
  /** Optional fragment template; otherwise render the page template without its layout. */
11
11
  fragment?: string;
12
12
  }
13
+ interface NamespacedViewRenderer {
14
+ render: ViewRenderer["render"];
15
+ renderHtmx: ViewRenderer["renderHtmx"];
16
+ }
13
17
  /** App-owned renderer; request data stays in the engine's async resolver. */
14
18
  declare class ViewRenderer {
15
19
  private readonly engine;
16
20
  private readonly defaultLayout?;
17
21
  constructor(engine: ViewEngine, defaultLayout?: string | false | undefined);
22
+ /** Explicit template prefix; layout names and response contracts remain unchanged. */
23
+ namespace(prefix: string): NamespacedViewRenderer;
18
24
  render(name: string, data?: Record<string, unknown>, options?: ViewResponseOptions): Promise<Response>;
19
25
  /** Opt-in representation selection; auth/CSRF/tenancy remain the route's responsibility. */
20
26
  renderHtmx(name: string, data?: Record<string, unknown>, options?: HtmxViewResponseOptions): Promise<Response>;
21
27
  }
22
28
  declare const CORE_VIEW_RENDERER_TOKEN: import("../contracts/container").ServiceToken<ViewRenderer>;
23
- export type { HtmxViewResponseOptions, ViewResponseOptions };
29
+ export type { HtmxViewResponseOptions, NamespacedViewRenderer, ViewResponseOptions };
24
30
  export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer };
@@ -141,7 +141,16 @@ function createCorsMiddleware(options = {}) {
141
141
  const response = await next();
142
142
  const headers = new Headers(response.headers);
143
143
  for (const [key, value] of buildCorsHeaders(request, captured)) {
144
- headers.set(key, value);
144
+ if (key === "vary") {
145
+ const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
146
+ if (fields.includes("*"))
147
+ continue;
148
+ if (!fields.some((field) => field.toLowerCase() === "origin"))
149
+ fields.push(value);
150
+ headers.set("Vary", fields.join(", "));
151
+ } else {
152
+ headers.set(key, value);
153
+ }
145
154
  }
146
155
  return new Response(response.body, {
147
156
  status: response.status,
@@ -1,8 +1,52 @@
1
1
  // @bun
2
2
  // ../../src/core/http/route.ts
3
+ function resourceRoutes(path, actions) {
4
+ if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) {
5
+ throw new TypeError("Resource path must be a static absolute path without a trailing slash.");
6
+ }
7
+ const routes = {};
8
+ const definitions = {
9
+ index: [path, ["GET"]],
10
+ create: [`${path}/create`, ["GET"]],
11
+ store: [path, ["POST"]],
12
+ show: [`${path}/:id`, ["GET"]],
13
+ edit: [`${path}/:id/edit`, ["GET"]],
14
+ update: [`${path}/:id`, ["PUT", "PATCH"]],
15
+ destroy: [`${path}/:id`, ["DELETE"]]
16
+ };
17
+ for (const [action, handler] of Object.entries(actions)) {
18
+ if (!Object.hasOwn(definitions, action) || typeof handler !== "function") {
19
+ throw new TypeError(`Invalid resource action: ${action}`);
20
+ }
21
+ const [route, methods] = definitions[action];
22
+ const methodMap = routes[route] ?? {};
23
+ routes[route] = methodMap;
24
+ for (const method of methods)
25
+ methodMap[method] = handler;
26
+ }
27
+ return routes;
28
+ }
29
+ function mergeRouteMaps(...maps) {
30
+ const routes = {};
31
+ for (const map of maps) {
32
+ for (const [path, handler] of Object.entries(map)) {
33
+ if (Object.hasOwn(routes, path))
34
+ throw new Error(`Duplicate route path: ${path}`);
35
+ Object.defineProperty(routes, path, {
36
+ value: handler,
37
+ enumerable: true,
38
+ writable: true,
39
+ configurable: true
40
+ });
41
+ }
42
+ }
43
+ return routes;
44
+ }
3
45
  function getRouteParams(request) {
4
46
  return request.params;
5
47
  }
6
48
  export {
7
- getRouteParams
49
+ getRouteParams,
50
+ mergeRouteMaps,
51
+ resourceRoutes
8
52
  };
@@ -107,8 +107,8 @@ export { createRequireGlobalAdminMiddleware } from "../core/http/requireGlobalAd
107
107
  export { createRequireWebAuthMiddleware } from "../core/http/requireWebAuthMiddleware.ts";
108
108
  export { JsonResource, ResourceCollection, serializeDate, toPaginatedResourceCollection, toResourceCollection, whenLoaded, } from "../core/http/resources.ts";
109
109
  export { createdResponse, errorResponse, jsonResponse, noContentResponse, withErrorHandling, } from "../core/http/response.ts";
110
- export type { RouteRequest } from "../core/http/route.ts";
111
- export { getRouteParams } from "../core/http/route.ts";
110
+ export type { ResourceActions, ResourceMemberHandler, ResourceRouteMap, RouteRequest, } from "../core/http/route.ts";
111
+ export { getRouteParams, mergeRouteMaps, resourceRoutes } from "../core/http/route.ts";
112
112
  export { loginRedirectLocation, safeInternalRedirectPath, sanitizeInternalPath, } from "../core/http/safeInternalPath.ts";
113
113
  export { createScimThrottleMiddleware } from "../core/http/scimThrottleMiddleware.ts";
114
114
  export { createSecurityHeadersMiddleware } from "../core/http/securityHeadersMiddleware.ts";
@@ -171,5 +171,5 @@ export type { ValidationRule, ValidationSchema } from "../core/validation/rules.
171
171
  export { emailRule, maxLength, minLength, required, stringRule, validateObject, } from "../core/validation/rules.ts";
172
172
  export { CORE_VIEW_RENDERER_TOKEN, configureWebErrorView, configureWebLayoutData, createWebLayoutDataResolver, DEFAULT_VIEWS_DIRECTORY, EtaViewEngine, errorTemplateName, htmlErrorResponse, htmlResponse, isHtmxRequest, notFoundHtmlResponse, redirectResponse, renderKernelErrorChrome, renderWebErrorHtml, resolveWebLayoutData, rssResponse, textResponse, ViewRenderer, xmlResponse, } from "../core/view/index.ts";
173
173
  export type { ViewEngine } from "../core/view/viewEngine.ts";
174
- export type { HtmxViewResponseOptions, ViewResponseOptions } from "../core/view/viewRenderer.ts";
174
+ export type { HtmxViewResponseOptions, NamespacedViewRenderer, ViewResponseOptions, } from "../core/view/viewRenderer.ts";
175
175
  export type { WebLayoutAuthUser, WebLayoutData, WebLayoutDataOptions, WebLayoutUserKey, } from "../core/view/webLayoutData.ts";
package/dist/index.js CHANGED
@@ -11352,7 +11352,16 @@ function createCorsMiddleware(options = {}) {
11352
11352
  const response = await next();
11353
11353
  const headers = new Headers(response.headers);
11354
11354
  for (const [key, value] of buildCorsHeaders(request, captured)) {
11355
- headers.set(key, value);
11355
+ if (key === "vary") {
11356
+ const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
11357
+ if (fields.includes("*"))
11358
+ continue;
11359
+ if (!fields.some((field) => field.toLowerCase() === "origin"))
11360
+ fields.push(value);
11361
+ headers.set("Vary", fields.join(", "));
11362
+ } else {
11363
+ headers.set(key, value);
11364
+ }
11356
11365
  }
11357
11366
  return new Response(response.body, {
11358
11367
  status: response.status,
@@ -12390,6 +12399,48 @@ function withErrorHandling(handler) {
12390
12399
  };
12391
12400
  }
12392
12401
  // ../../src/core/http/route.ts
12402
+ function resourceRoutes(path, actions) {
12403
+ if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) {
12404
+ throw new TypeError("Resource path must be a static absolute path without a trailing slash.");
12405
+ }
12406
+ const routes = {};
12407
+ const definitions = {
12408
+ index: [path, ["GET"]],
12409
+ create: [`${path}/create`, ["GET"]],
12410
+ store: [path, ["POST"]],
12411
+ show: [`${path}/:id`, ["GET"]],
12412
+ edit: [`${path}/:id/edit`, ["GET"]],
12413
+ update: [`${path}/:id`, ["PUT", "PATCH"]],
12414
+ destroy: [`${path}/:id`, ["DELETE"]]
12415
+ };
12416
+ for (const [action, handler] of Object.entries(actions)) {
12417
+ if (!Object.hasOwn(definitions, action) || typeof handler !== "function") {
12418
+ throw new TypeError(`Invalid resource action: ${action}`);
12419
+ }
12420
+ const [route, methods] = definitions[action];
12421
+ const methodMap = routes[route] ?? {};
12422
+ routes[route] = methodMap;
12423
+ for (const method of methods)
12424
+ methodMap[method] = handler;
12425
+ }
12426
+ return routes;
12427
+ }
12428
+ function mergeRouteMaps(...maps) {
12429
+ const routes = {};
12430
+ for (const map of maps) {
12431
+ for (const [path, handler] of Object.entries(map)) {
12432
+ if (Object.hasOwn(routes, path))
12433
+ throw new Error(`Duplicate route path: ${path}`);
12434
+ Object.defineProperty(routes, path, {
12435
+ value: handler,
12436
+ enumerable: true,
12437
+ writable: true,
12438
+ configurable: true
12439
+ });
12440
+ }
12441
+ }
12442
+ return routes;
12443
+ }
12393
12444
  function getRouteParams(request) {
12394
12445
  return request.params;
12395
12446
  }
@@ -16119,6 +16170,11 @@ class EtaViewEngine {
16119
16170
  }
16120
16171
  }
16121
16172
  // ../../src/core/view/viewRenderer.ts
16173
+ function validateRelativeViewName(name) {
16174
+ if (!/^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(name) || name.split("/").some((part) => part === "." || part === "..")) {
16175
+ throw new TypeError("View namespace and names must be relative template paths without dot segments.");
16176
+ }
16177
+ }
16122
16178
  var HTMX_VARY_HEADERS = ["HX-Request", "HX-Boosted", "HX-History-Restore-Request"];
16123
16179
  function varyByHtmx(headers) {
16124
16180
  const existing = (headers.get("Vary") ?? "").split(",").map((field) => field.trim());
@@ -16139,6 +16195,20 @@ class ViewRenderer {
16139
16195
  this.engine = engine;
16140
16196
  this.defaultLayout = defaultLayout;
16141
16197
  }
16198
+ namespace(prefix) {
16199
+ validateRelativeViewName(prefix);
16200
+ const qualify = (name) => {
16201
+ validateRelativeViewName(name);
16202
+ return `${prefix}/${name}`;
16203
+ };
16204
+ return {
16205
+ render: async (name, data, options) => this.render(qualify(name), data, options),
16206
+ renderHtmx: async (name, data, options = {}) => this.renderHtmx(qualify(name), data, {
16207
+ ...options,
16208
+ ...options.fragment === undefined ? {} : { fragment: qualify(options.fragment) }
16209
+ })
16210
+ };
16211
+ }
16142
16212
  async render(name, data = {}, options = {}) {
16143
16213
  const html = await this.engine.render(name, data, {
16144
16214
  request: options.request,
@@ -16534,6 +16604,7 @@ export {
16534
16604
  maxLength,
16535
16605
  membershipContext,
16536
16606
  membershipRepository,
16607
+ mergeRouteMaps,
16537
16608
  migrateDatabase,
16538
16609
  minLength,
16539
16610
  modelEventName,
@@ -16636,6 +16707,7 @@ export {
16636
16707
  resolveUserId,
16637
16708
  resolveUserTenantId,
16638
16709
  resolveWebLayoutData,
16710
+ resourceRoutes,
16639
16711
  rollbackDatabase,
16640
16712
  rssResponse,
16641
16713
  runDueScheduledTasks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.13",
3
+ "version": "2.2.14",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",